Day 22:收斂進一個具名專案,訂單服務實戰整合版啟動 結尾說得很坦白:骨架能動,不代表它已經具備任何高併發防護。OrderService.getOrderDetail 裡呼叫 stockClient.checkStock(orderId) 那一行,此刻還是最原始、沒有任何保護的直白呼叫,數千個請求同時湧入時,這行程式碼會毫無節制地被同時發起。今天要做的事情很單純,把這個留白補上。
先把場景收斂到具體位置。Day 22 展示的 getOrderDetail 依序做兩件事,先問資料庫拿訂單資料,再呼叫內部服務確認庫存狀態。前者是本地資料庫查詢,後者是呼叫另一個服務,兩者的風險完全不對等。今天要處理的第一個判斷,是找出方法裡哪一段呼叫真正暴露在外部依賴的風險之下,而非讓整個方法籠統地一起加上防護。
除了查詢訂單詳情這個切片,Day 13:協程搭配訊息佇列與外部 API 呼叫的常見模式 也留下了一段值得回頭處理的程式碼:扣庫存成功後,用 async 平行發起確認優惠資格與發送通知這兩個互不依賴的呼叫。那篇文章當時刻意不處理例外客製化與逾時控制,留到後面再談。今天就是那個「後面」,這兩段程式碼,一段是 Day 22 骨架裡的下游查詢,一段是 Day 13 留下的平行呼叫情境,會是本篇實際動手的兩個對象。
動手寫程式碼之前,先把判斷依據講清楚,這樣讀者遇到自己專案裡其他方法時,也知道該怎麼決定要不要裝上這些機制,而不是看到一段程式碼就反射性地到處包一層 Semaphore。
第一個判斷依據,回扣 Day 15:併發限制,用 Semaphore 保護下游別被打爆 建立的邏輯:這段程式碼是不是在呼叫一個有已知處理能力上限的外部依賴。如果答案是肯定的,而且流量有可能瞬間暴衝到超過那個上限,這裡就是 Semaphore 節流該出現的位置。第二個判斷依據,回扣 Day 16:逾時與取消,讓卡住的協程別拖垮整個系統 建立的邏輯:這段程式碼呼叫的對象,會不會存在不可預期地延遲或掛起風險,簡單講,就是對方會不會偶爾卡住不回應。如果會,這裡就該搭配 withTimeout 設一個執行時間上限。
兩個判斷依據不是互斥的,一段程式碼完全可能同時符合兩者,這種情況下 Semaphore 與 withTimeout 會一起出現在同一段呼叫外層,這件事等一下寫程式碼時會直接看到。
把這兩個依據套進 getOrderDetail 逐行檢查一遍。orderRepository.findById(orderId) 呼叫的是本地資料庫,走的是 R2DBC 這條非阻塞連線,風險相對可控,也沒有已知的外部處理能力上限問題,今天不動它。stockClient.checkStock(orderId) 呼叫的是另一個內部服務,符合前面兩個判斷依據,是今天要優先裝上防護的對象。同樣的邏輯套進 Day 13 那段平行呼叫,確認優惠資格與發送通知,這兩個呼叫都是對外部依賴的呼叫,理論上都值得評估,但為了避免篇幅膨脹成把每個外部呼叫都完整示範一次,今天只挑其中一個,確認優惠資格,作為代表,讀者可以照同樣的邏輯自行套用到發送通知那一段。
回到 Day 22:收斂進一個具名專案,訂單服務實戰整合版啟動 展示的 OrderService.getOrderDetail,這是接下來擴充的起點:
suspend fun getOrderDetail(orderId: Long): OrderDetailResponse {
val order = checkNotNull(orderRepository.findById(orderId))
val stockStatus = stockClient.checkStock(orderId)
return OrderDetailResponse(
orderId = order.id,
status = order.status,
stockAvailable = stockStatus.available,
)
}
今天要動的,就是中間那一行 stockClient.checkStock(orderId)。在 OrderService 裡新增一個 Semaphore 屬性,並把這行呼叫包進 withPermit { }:
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val stockClient: StockClient,
) {
private val stockCheckSemaphore = Semaphore(permits = 20)
suspend fun getOrderDetail(orderId: Long): OrderDetailResponse {
val order = checkNotNull(orderRepository.findById(orderId))
val stockStatus = stockCheckSemaphore.withPermit {
stockClient.checkStock(orderId)
}
return OrderDetailResponse(
orderId = order.id,
status = order.status,
stockAvailable = stockStatus.available,
)
}
}
orderRepository.findById(orderId) 這一行完全沒有變動,方法簽章、參數型別、回傳型別也都跟 Day 22 一模一樣,只有中間那行呼叫下游服務的地方,多包了一層 withPermit { }。permits = 20 這個數字沿用 Day 15:併發限制,用 Semaphore 保護下游別被打爆 當時評估的假設,確認庫存這個內部服務最多能同時處理 20 個併發呼叫,超過這個數字回應時間就會開始惡化。實際數字該設多少,終究要看那個下游服務團隊實際給出的處理能力評估,這裡沿用的只是示範情境裡已經定案的假設值,不是放諸四海皆準的建議。
節流解決的是同時執行數量的問題,但沒有解決「已經拿到名額、正在執行的呼叫卡住不回應」這個問題。這正是 Day 16:逾時與取消,讓卡住的協程別拖垮整個系統 定案的逾時機制要補上的部分。把 withTimeout 疊加在 withPermit { } 裡面:
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val stockClient: StockClient,
) {
private val stockCheckSemaphore = Semaphore(permits = 20)
suspend fun getOrderDetail(orderId: Long): OrderDetailResponse {
val order = checkNotNull(orderRepository.findById(orderId))
val stockStatus = stockCheckSemaphore.withPermit {
try {
withTimeout(2_000) {
stockClient.checkStock(orderId)
}
} catch (e: TimeoutCancellationException) {
null
}
}
return OrderDetailResponse(
orderId = order.id,
status = order.status,
stockAvailable = stockStatus?.available ?: false,
)
}
}
跟 Day 22 的版本相比,新增的內容只有三塊:stockCheckSemaphore 這個屬性、包住呼叫的 withPermit { },以及疊在裡面的 withTimeout(2_000) 搭配 try-catch。getOrderDetail 的方法簽章一個字都沒有動,OrderDetailResponse 的欄位結構也完全沒有變化,這正是這套骨架設計的用意,控制手段是加在既有邏輯外層,而不是推翻重寫。
這裡刻意不去憑空建構一個新的庫存狀態物件來代表逾時,Day 22 骨架只交代了 stockClient.checkStock 回傳一個含 available 欄位的物件,沒有交代這個型別本身能不能被自由建構。逾時發生時讓 stockStatus 直接落為 null,最後組裝 OrderDetailResponse 那一步用 ?: 補上預設值 false,一樣能表達查不到就當作暫時不可用這個判斷,卻不需要對一個規格未定義建構方式的型別動手腳。
這裡選擇兩秒作為逾時上限,沿用 Day 16 當時的假設數值,理由也是同一套:秒數沒有放諸四海皆準的答案,關鍵在於權衡正常回應的合理延遲範圍,與使用者願意等待的耐心上限。設太短容易把正常但稍慢的回應誤判成逾時,設太長又失去主動介入的意義,這是每個團隊需要依照自己下游服務的實際回應時間分布去校準的數字,不是抄一個公版數字就能一勞永逸。
逾時發生時,讓 getOrderDetail 回傳一個標示為不可用的庫存狀態,而不是讓例外整個往上拋、讓查詢訂單這個請求直接失敗,是這裡的設計選擇。理由是查詢訂單詳情這個操作,就算庫存狀態暫時查不到,訂單本身的資料還是有意義的,寧可讓使用者看到庫存暫時無法確認,也不要讓一個下游服務的逾時拖垮整個查詢請求。這個選擇不是唯一正確答案,換一個情境,例如庫存狀態是這個請求不可或缺的關鍵資訊,讓例外往上拋、由呼叫端決定怎麼處理,可能才是更合理的設計,這裡只是示範其中一種權衡方向。
Semaphore 與 withTimeout 疊加使用時,順序也有講究。withPermit { } 包在外層,withTimeout 包在內層,代表排隊等候名額的這段時間不會被算進兩秒的逾時上限裡,逾時計時只從真正取得名額、開始執行呼叫的那一刻算起。如果反過來把 withTimeout 包在外層、withPermit { } 包在內層,排隊等候名額的時間會被計入逾時倒數,協程可能還沒真正開始呼叫下游服務,就已經因為排隊排太久而被判定逾時,這通常不是原本想要的行為。兩者疊加的順序,本身就是一個需要留意的設計細節。

Day 13:協程搭配訊息佇列與外部 API 呼叫的常見模式 展示過 deductStockAndFollowUp 這個方法,扣庫存成功後用 async 平行發起確認優惠資格與發送通知兩個呼叫。那篇文章當時明確說了,例外處理的完整客製化邏輯與逾時控制不在範圍內,留到後面處理。今天把確認優惠資格這一段,套用今天建立的同一套判斷依據補上防護,一樣只新增,不更動原本的方法簽章:
private val eligibilitySemaphore = Semaphore(permits = 20)
suspend fun deductStockAndFollowUp(orderId: String, memberId: String): OrderFollowUpResult =
coroutineScope {
val deductResult = deductStock(orderId)
val eligibilityDeferred = async {
eligibilitySemaphore.withPermit {
try {
withTimeout(1_500) {
checkPromotionEligibility(memberId)
}
} catch (e: TimeoutCancellationException) {
false
}
}
}
val notifyDeferred = async { sendOrderConfirmedNotification(orderId) }
val eligibility = eligibilityDeferred.await()
val notifyResult = notifyDeferred.await()
OrderFollowUpResult(
stockDeducted = deductResult,
promotionEligible = eligibility,
notified = notifyResult,
)
}
跟 Day 13 的原始版本相比,改動只發生在 eligibilityDeferred 這一個 async 區塊內部,checkPromotionEligibility(memberId) 這個呼叫外層多包了 eligibilitySemaphore.withPermit { } 與 withTimeout(1_500) 兩層,逾時發生時讓這段回傳 false,代表暫時無法確認優惠資格。notifyDeferred 那一行維持原樣沒有變動。這裡刻意只示範其中一個,讀者可以照同樣的邏輯自行判斷發送通知那一段是否也需要類似的防護,不需要每個外部呼叫都在文章裡完整展示一次,重點是判斷依據本身,而不是窮舉每一種呼叫的寫法。
這裡也連帶回應 Day 13 當時提出但沒有處理的疑問:確認優惠資格如果因為逾時而拋出例外,這個例外會沿著 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 定案的結構化並發規則往上傳播,可能連帶取消還在執行中的發送通知協程。今天的版本把逾時例外用 try-catch 攔在 async 區塊內部,不讓它真的往外拋,這樣一來確認優惠資格逾時這件事,不會意外牽連到本該正常送達的通知。這個做法呼應了 Day 13 當時點出的方向,例外處理的客製化能避免兩個彼此獨立的操作互相牽連,只是今天用逾時攔截取代了完整的例外處理機制展示。
今天把 Semaphore 與 withTimeout 實際裝進了 getOrderDetail 與 deductStockAndFollowUp 這兩個方法,讀者手上現在有了具體的判斷依據:什麼樣的呼叫位置該用節流,什麼樣的呼叫位置該加逾時,兩者又該怎麼疊加、順序怎麼安排。這些防護處理的都是同一個層次的問題,呼叫端該怎麼合理地發起請求,這正是 Day 15:併發限制,用 Semaphore 保護下游別被打爆 一路建立下來的問題邊界。
坦白講,這裡還有一個問題今天完全沒有碰。今天裝上的所有防護,保護的都是查詢訂單詳情這條路徑上呼叫確認庫存服務的行為,還有確認優惠資格這個外部呼叫。真正動到庫存數量本身的那個操作,扣減庫存,今天一行都沒有動。如果同時有多個請求都要修改同一筆庫存資料,就算每一台伺服器上的 Semaphore 都設定得再合理、逾時秒數抓得再精準,這些機制全部只發生在單一應用程式行程內部,管不到另一台伺服器上同時在跑的另一個協程,也管不到資料庫裡那一列庫存資料實際上正在被誰讀取、被誰寫入。呼叫端的節流做得再好,庫存資料被錯誤修改的風險,並沒有因此消失一分一毫。
這個問題,今天裝上的任何機制都沒有解決。
下一篇會正式處理:庫存扣減的併發正確性。
《Day 24:庫存扣減的併發正確性,用資料庫機制取代分散式鎖》 見。